import type { Metadata } from 'next'; import { notFound, redirect } from 'next/navigation'; import { t } from '@/i18n'; import { api, isNotBuilt, safe } from '@/lib/api'; import { apiCompare } from '@/lib/api-compare'; import { MIN_COMPARE_COUNTRIES, compareCanonicalQuery, parseCompareState, splitCompareSlugs, type CompareState } from '@/lib/compare-state'; import { routes } from '@/lib/site'; import { topicById } from '@/lib/topics'; import type { CountrySummary, IndicatorCard } from '@/lib/types'; import { apiModeOf, toCountryLite, type CompareSeries, type CompareSnapshotRow, type CountryLite } from '@/lib/types-compare'; import { seoTitle } from '@/lib/seo'; import { HeadToHead } from '@/components/compare/head-to-head'; import { CompareChart } from '@/components/compare/compare-chart'; import { ChartGrid, ChartSections } from '@/components/compare/chart-sections'; import { CompareControls } from '@/components/compare/compare-controls'; import { CustomPanel } from '@/components/compare/custom-panel'; import { SnapshotTable } from '@/components/compare/snapshot-table'; import { NotBuiltState } from '@/components/data/empty-state'; import { Section } from '@/components/data/section'; export const revalidate = 900; type Params = { slugs: string[] }; type SP = Record; const EAGER = 4; // charts rendered with server-fetched series; the rest fetch on scroll /** Resolve path segments (slugs or ISO3) against the country list, preserving order; null when the API is not built. */ async function resolve(segments: string[]): Promise<{ countries: CountrySummary[]; all: CountrySummary[] } | 'not-built'> { let list; try { list = await api.countries(); } catch (e) { if (isNotBuilt(e)) return 'not-built'; throw e; } const bySlug = new Map(); for (const c of list.items) { if (c.slug) bySlug.set(c.slug.toLowerCase(), c); bySlug.set(c.id.toLowerCase(), c); } const seen = new Set(); const countries: CountrySummary[] = []; for (const s of splitCompareSlugs(segments)) { const c = bySlug.get(s); if (c && !seen.has(c.id)) { seen.add(c.id); countries.push(c); } } return { countries, all: list.items }; } function namesOf(cs: CountrySummary[]): string { return cs.map((c) => c.name ?? c.id).join(` ${t('compare.vs')} `); } export async function generateMetadata({ params, searchParams }: { params: Promise; searchParams: Promise }): Promise { const [{ slugs }, sp] = await Promise.all([params, searchParams]); const r = await resolve(slugs); if (r === 'not-built' || r.countries.length < MIN_COMPARE_COUNTRIES) return { title: t('compare.notFound'), robots: { index: false } }; const state = parseCompareState(sp); const names = namesOf(r.countries); const tabName = state.tab === 'snapshot' ? null : state.tab === 'custom' ? t('compare.tab.custom') : topicById(state.tab)?.name ?? state.tab; const title = tabName ? `${seoTitle.compare(r.countries.map((c) => c.name ?? c.id))} — ${tabName}` : seoTitle.compare(r.countries.map((c) => c.name ?? c.id)); const description = t('compare.pageDescription', { names, list: 'GDP, GDP per capita, growth, inflation, unemployment, life expectancy' }); const canonical = `${routes.compare(...r.countries.map((c) => c.slug ?? c.id))}${compareCanonicalQuery(state)}`; return { title, description, alternates: { canonical }, robots: state.tab === 'custom' ? { index: false, follow: true } : undefined, openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article', images: [{ url: routes.compareOg(r.countries.map((c) => c.slug ?? c.id)), width: 1200, height: 630, alt: names }] }, twitter: { card: 'summary_large_image', title, description, images: [routes.compareOg(r.countries.map((c) => c.slug ?? c.id))] }, }; } export default async function CompareViewPage({ params, searchParams }: { params: Promise; searchParams: Promise }) { const [{ slugs: segments }, sp] = await Promise.all([params, searchParams]); const r = await resolve(segments); if (r === 'not-built') return ; // A single country (e.g. the "Compare" action of a country page) opens the builder with it pre-selected. if (r.countries.length === 1) redirect(`${routes.compare()}?c=${r.countries[0]!.slug ?? r.countries[0]!.id.toLowerCase()}`); if (r.countries.length < MIN_COMPARE_COUNTRIES) notFound(); let state: CompareState = parseCompareState(sp); // `?indicator=` without a tab → custom tab with that single indicator (the snapshot table always sets a tab). if (state.indicator && state.tab === 'snapshot') state = { ...state, tab: 'custom', indicators: Array.from(new Set([state.indicator, ...state.indicators])).slice(0, 6) }; const countries: CountryLite[] = r.countries.map(toCountryLite); const all: CountryLite[] = r.all.filter((c) => c.kind !== 'aggregate').map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name)); const ids = countries.map((c) => c.id); const slugs = countries.map((c) => c.slug); const names = namesOf(r.countries); // Data for the active tab const topic = state.tab !== 'snapshot' && state.tab !== 'custom' ? state.tab : null; const customSlugs = state.tab === 'custom' ? state.indicators : []; const snapshotP = state.tab === 'custom' ? (customSlugs.length ? safe(apiCompare.snapshot(ids, { indicators: customSlugs })) : Promise.resolve(null)) : safe(apiCompare.snapshot(ids, { topic })); const heroP = state.indicator && state.tab !== 'snapshot' ? safe(apiCompare.compare(ids, [state.indicator], { from: state.from, to: state.to, mode: apiModeOf(state.mode) })) : Promise.resolve(null); const [snapshot, hero] = await Promise.all([snapshotP, heroP]); const rows: CompareSnapshotRow[] = (snapshot?.rows ?? []).filter((row) => ids.some((id) => row.values[id]?.has_data)); const maxYear = Math.max(new Date().getUTCFullYear(), ...rows.flatMap((row) => Object.values(row.values).map((v) => v.year ?? 0))); // Eager series for the first charts of a chart tab (one request, ≤ 8 indicators). let eager: Map = new Map(); if (topic || state.tab === 'custom') { const eagerSlugs = rows .map((row) => row.indicator.slug) .filter((s) => s !== state.indicator) .slice(0, state.tab === 'custom' ? 6 : EAGER); if (eagerSlugs.length) { const bundle = await safe(apiCompare.compare(ids, eagerSlugs, { from: state.from, to: state.to, mode: apiModeOf(state.mode) })); if (bundle) { eager = new Map(eagerSlugs.map((s) => [s, bundle.series.filter((x) => x.indicator.slug === s)])); } } } const heroCard: IndicatorCard | null = hero?.indicators[0] ?? rows.find((row) => row.indicator.slug === state.indicator)?.indicator ?? null; const heroRow = rows.find((row) => row.indicator.slug === state.indicator) ?? null; const gridRows = rows.filter((row) => row.indicator.slug !== state.indicator); const downloadIndicators = rows.map((row) => row.indicator.slug).slice(0, 40); const downloadHref = downloadIndicators.length ? routes.compareDownload(ids, downloadIndicators, { from: state.from, to: state.to }) : null; const topicDef = topic ? topicById(topic) : null; const tabLabel = state.tab === 'snapshot' ? t('compare.tab.snapshot') : state.tab === 'custom' ? t('compare.tab.custom') : (topicDef?.name ?? state.tab); return ( <>
{t('compare.title')}

{r.countries.map((c, i) => ( {i > 0 ? {t('compare.vs')} : null} {c.flag} {c.name} ))}

{t('compare.headingTab', { names, tab: tabLabel })}

{t('compare.colourNote')}

{state.tab === 'snapshot' && countries.length === 2 ? (
) : null} {state.tab === 'snapshot' ? (
) : null} {state.tab !== 'snapshot' ? ( <> {state.tab === 'custom' ? (
row.indicator)} state={state} />
) : null} {state.indicator && heroCard ? (
) : null} {topic ? (
{gridRows.length === 0 ? (

{t('compare.charts.none', { topic: (topicDef?.short ?? topic).toLowerCase() })}

) : ( )}
) : null} {state.tab === 'custom' && gridRows.length ? (
) : null} {rows.length ? (
) : null} ) : null} ); }